# 面经手册 · 第32篇《MyBatis #{}${} 区别是什么?从SQL注入到预编译深度解析》

作者:小傅哥
博客:https://bugstack.cn (opens new window)

沉淀、分享、成长,让自己和他人都能有所收获!😄

# 一、前言

面试场上,#{}${} 的区别,几乎是 MyBatis 八股文的必考题。

但大多数候选人只能背出"#{} 防注入、${} 不防注入"就草草收场。面试官追问一句:为什么 #{} 能防注入?底层是怎么处理的?模糊查询 like 该怎么写?${} 在什么场景下非用不可?——大多数人就卡住了。

本文从 JDBC 预编译原理出发,结合 MyBatis 源码,把 #{}${} 的区别彻底讲透。不只是面试能答上来,更要知道为什么。

# 二、面试题

谢飞机,小记!,最近突击了 MyBatis 八股文,信心满满来面试。

面试官:谢飞机,MyBatis 中 #{}${} 的区别是什么?

谢飞机#{} 是预编译占位符,会替换成 ?,可以防止 SQL 注入;${} 是字符串拼接,直接把值拼到 SQL 里,有注入风险。

面试官:嗯,那为什么 #{} 能防注入?底层原理是什么?

谢飞机:因为用的是 PreparedStatement... 预编译嘛...

面试官:预编译为什么就能防注入?给个具体的例子说明。

谢飞机:嗯... 就是... 参数化查询?

面试官:好吧。那模糊查询 like 语句用 #{} 怎么写?用 ${} 有什么问题?

谢飞机:like 用... concat?

面试官${} 在什么场景下必须用,不能用 #{}

谢飞机:... 表名?列名?

面试官:对,但为什么 #{} 不能用在表名列名上?你清楚吗?

谢飞机:我... 再见!ヾ( ̄▽ ̄)

# 三、JDBC 预编译基础

在讲 MyBatis 的 #{}${} 之前,必须先搞清楚 JDBC 的预编译机制。这是理解两者区别的根基。

# 1. Statement vs PreparedStatement

// 方式一:Statement — 字符串拼接,有 SQL 注入风险
Statement stmt = conn.createStatement();
String sql = "SELECT * FROM user WHERE name = '" + name + "'";
ResultSet rs = stmt.executeQuery(sql);

// 方式二:PreparedStatement — 预编译 + 参数化,防注入
String sql = "SELECT * FROM user WHERE name = ?";
PreparedStatement ps = conn.prepareStatement(sql);
ps.setString(1, name);
ResultSet rs = ps.executeQuery();
1
2
3
4
5
6
7
8
9
10

关键区别

对比项 Statement PreparedStatement
SQL 构建 字符串拼接 占位符 ? + 参数设置
编译时机 每次执行都编译 预编译一次,可复用
SQL 注入 ❌ 有风险 ✅ 安全
性能 重复 SQL 效率低 预编译缓存,效率高

# 2. PreparedStatement 为什么能防注入?

来看一个经典的 SQL 注入案例:

// 用户输入:name = "admin' OR '1'='1"

// Statement 拼接后的 SQL:
// SELECT * FROM user WHERE name = 'admin' OR '1'='1'
// 结果:查出所有用户数据!

// PreparedStatement 处理方式:
// SQL 模板:SELECT * FROM user WHERE name = ?
// 参数设置:setString(1, "admin' OR '1'='1")
// 实际执行:SELECT * FROM user WHERE name = 'admin\' OR \'1\'=\'1'
// 结果:把整个输入当作一个字符串值来匹配,注入失效
1
2
3
4
5
6
7
8
9
10
11

核心原理:PreparedStatement 在设置参数值时,会对特殊字符(如单引号 ')进行转义处理。数据库引擎在编译 SQL 模板时就已经确定了语法结构(SELECT、WHERE、= 等关键字和逻辑关系已固定),参数值不会改变 SQL 的语法结构,因此注入的恶意 SQL 片段只会被当作普通字符串数据处理。

# 3. 预编译的两个阶段

阶段一:编译(Compile)
  SQL 模板发送给数据库 → 数据库解析语法 → 生成执行计划
  此时 ? 只是占位,不参与语法解析

阶段二:执行(Execute)
  将参数值填充到 ? 位置 → 执行已编译的执行计划
  参数值只作为数据,不会被解析为 SQL 语法
1
2
3
4
5
6
7

一句话总结:预编译之所以能防注入,是因为语法解析和参数填充是分离的,参数永远无法改变已编译的 SQL 语法结构。

# 四、MyBatis #{} 源码解析

# 1. #{} 的处理过程

MyBatis 在解析 SQL 映射时,会经历以下步骤:

XML 映射文件中的 SQL
  ↓  SqlSource 解析
GenericTokenParser 解析 `#{}` 标记
  ↓  替换为 ?
ParameterMappingTokenHandler 记录参数映射
  ↓  生成 ParameterMapping 列表
最终 SQL:SELECT * FROM user WHERE name = ?
1
2
3
4
5
6
7

# 2. 核心源码追踪

步骤一:SQL 解析入口

// org.apache.ibatis.builder.SqlSourceBuilder
public SqlSource parse(String originalSql, Class<?> parameterType, Map<String, Object> additionalParameters) {
    ParameterMappingTokenHandler handler = new ParameterMappingTokenHandler(
        configuration, parameterType, additionalParameters);
    GenericTokenParser parser = new GenericTokenParser("#{", "}", handler);
    String sql = parser.parse(originalSql);
    return new StaticSqlSource(configuration, sql, handler.getParameterMappings());
}
1
2
3
4
5
6
7
8

GenericTokenParser 负责识别 #{} 之间的内容,提取后交给 ParameterMappingTokenHandler 处理。

步骤二:Token 处理器

// org.apache.ibatis.builder.SqlSourceBuilder.ParameterMappingTokenHandler
private static class ParameterMappingTokenHandler extends BaseBuilder implements TokenHandler {

    @Override
    public String handleToken(String content) {
        parameterMappings.add(buildParameterMapping(content));
        return "?";  // ← 关键:将 `#{}` 替换为 ?
    }
}
1
2
3
4
5
6
7
8
9

关键发现handleToken 方法直接返回 "?",这就是 #{} 被替换为占位符 ? 的地方。同时,参数的元信息(Java 类型、JDBC 类型、模式等)被保存到 ParameterMapping 列表中,供后续参数设置使用。

步骤三:参数设置

// org.apache.ibatis.scripting.defaults.DefaultParameterHandler
@Override
public void setParameters(PreparedStatement ps) {
    List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
    for (int i = 0; i < parameterMappings.size(); i++) {
        ParameterMapping parameterMapping = parameterMappings.get(i);
        String propertyName = parameterMapping.getProperty();
        Object value = metaObject.getValue(propertyName);
        TypeHandler typeHandler = parameterMapping.getTypeHandler();
        JdbcType jdbcType = parameterMapping.getJdbcType();
        // 通过 TypeHandler 将参数安全地设置到 PreparedStatement
        typeHandler.setParameter(ps, i + 1, value, jdbcType);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

每个 #{} 参数都通过对应的 TypeHandler 调用 PreparedStatement.setXxx() 方法安全设置,参数值不会参与 SQL 语法解析

# 3. #{} 支持的属性

#{property,javaType=int,jdbcType=NUMERIC,mode=IN,resultMap=...,typeHandler=...,numericScale=2}
1
属性 说明
javaType 参数的 Java 类型
jdbcType 参数的 JDBC 类型
mode 参数模式:IN / OUT / INOUT
typeHandler 指定类型处理器
numericScale 小数位数
resultMap 结果映射(OUT 模式)

# 五、MyBatis ${} 源码解析

# 1. ${} 的处理过程

XML 映射文件中的 SQL
  ↓  SqlSource 解析
GenericTokenParser 解析 `${}` 标记
  ↓  直接替换为字符串值
TextSqlSource / DynamicSqlSource
  ↓  无 ParameterMapping 记录
最终 SQL:SELECT * FROM user WHERE name = '张三'  ← 直接拼接
1
2
3
4
5
6
7

# 2. 核心源码追踪

步骤一:动态 SQL 解析

${} 的解析发生在动态 SQL 处理阶段(早于 #{} 的解析):

// org.apache.ibatis.scripting.xmltags.TextSqlNode
public class TextSqlNode implements SqlNode {
    private final String text;

    public TextSqlNode(String text) {
        this.text = text;
    }

    @Override
    public boolean apply(DynamicContext context) {
        // 使用 ${ } 解析器处理文本
        GenericTokenParser parser = new GenericTokenParser("${", "}",
            new BindingTokenParser(context, false));
        context.appendSql(parser.parse(text));
        return true;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

步骤二:Token 处理器

// org.apache.ibatis.scripting.xmltags.TextSqlNode.BindingTokenParser
private static class BindingTokenParser implements TokenHandler {

    @Override
    public String handleToken(String content) {
        Object value = OgnlCache.getValue(content, context.getBindings());
        // 直接将值 toString() 拼接到 SQL 中!
        return value == null ? "" : value.toString();
    }
}
1
2
3
4
5
6
7
8
9
10

关键区别${}handleToken 方法直接返回值的字符串形式,没有替换为 ?,没有 ParameterMapping,没有 TypeHandler。值被原样拼接到 SQL 字符串中。

# 3. ${} 的风险验证

<!-- Mapper XML -->
<select id="findUser" resultType="User">
    SELECT * FROM user WHERE name = '${name}'
</select>
1
2
3
4
// 调用:name = "admin' OR '1'='1"
userMapper.findUser("admin' OR '1'='1");

// 最终执行的 SQL:
// SELECT * FROM user WHERE name = 'admin' OR '1'='1'
// 注入成功!
1
2
3
4
5
6

# 六、核心对比总结

# 1. 一图看懂区别

`#{}` 处理链路:
  SQL: SELECT * FROM user WHERE name = #{name}
    → 解析:识别 #{name}
    → 替换:SELECT * FROM user WHERE name = ?
    → 参数设置:PreparedStatement.setString(1, "张三")
    → 执行:安全

`${}` 处理链路:
  SQL: SELECT * FROM user WHERE name = '${name}'
    → 解析:识别 ${name}
    → 替换:SELECT * FROM user WHERE name = '张三'
    → 无参数设置:直接执行拼接后的 SQL
    → 执行:有注入风险
1
2
3
4
5
6
7
8
9
10
11
12
13

# 2. 全面对比表

对比项 #{} ${}
本质 预编译占位符 字符串拼接替换
替换结果 ? 实际值的字符串
SQL 注入 ✅ 安全(参数化查询) ❌ 有风险(原样拼接)
参数类型处理 TypeHandler 自动转换 无处理,直接 toString()
编译时机 先编译 SQL 模板,再设参数 SQL 编译前就已拼接完成
性能 预编译缓存可复用 每次需要重新编译
适用场景 WHERE 条件值、INSERT 值等 表名、列名、ORDER BY 等
加引号 自动(由 JDBC 处理) 不加,需手动加引号

# 3. 处理顺序

MyBatis SQL 解析顺序:
  1. 先解析 `${}` → 字符串替换(DynamicSqlSource 阶段)
  2. 再解析 `#{}` → 替换为 ?(SqlSourceBuilder 阶段)
1
2
3

这意味着 ${} 替换后的内容,如果恰好包含 #{} 标记,还会被二次解析。这也可能导致额外的安全风险

# 七、高频面试场景

# 1. 模糊查询 like 怎么写?

错误写法

<!-- 直接拼接 `${}`,有注入风险 -->
SELECT * FROM user WHERE name LIKE '%${name}%'

<!-- `#{}` 放在 '' 内,会被当成普通字符串 -->
SELECT * FROM user WHERE name LIKE '%#{name}%'
<!-- 实际执行:LIKE '%?' — ? 不会被替换,直接报错 -->
1
2
3
4
5
6

正确写法一:CONCAT 函数

<select id="findUserByName" resultType="User">
    SELECT * FROM user WHERE name LIKE CONCAT('%', #{name}, '%')
</select>
1
2
3

正确写法二:bind 标签

<select id="findUserByName" resultType="User">
    <bind name="pattern" value="'%' + name + '%'" />
    SELECT * FROM user WHERE name LIKE #{pattern}
</select>
1
2
3
4

正确写法三:Java 层拼接

// Service 层
String pattern = "%" + name + "%";
userMapper.findUserByName(pattern);
1
2
3
<select id="findUserByName" resultType="User">
    SELECT * FROM user WHERE name LIKE #{pattern}
</select>
1
2
3

# 2. ${} 必须使用的场景

场景一:动态表名

<!-- `#{}` 替换为 ?,但表名位置不能放 ? -->
<!-- SELECT * FROM ?  — 语法错误! -->
<select id="findByTable" resultType="User">
    SELECT * FROM ${tableName} WHERE id = #{id}
</select>
1
2
3
4
5

为什么 #{} 不能用在表名?因为 PreparedStatement 的 ? 占位符只能用于值的位置(WHERE 条件、INSERT 值等),不能用于标识符的位置(表名、列名、关键字等)。数据库在编译 SQL 时需要知道表名才能生成执行计划,? 无法满足这个要求。

场景二:动态列名(ORDER BY)

<select id="findUsers" resultType="User">
    SELECT * FROM user ORDER BY ${column} ${order}
</select>
1
2
3

场景三:动态 SQL 关键字

<select id="findUsers" resultType="User">
    SELECT * FROM user ${whereClause}
</select>
1
2
3

# 3. ${} 的安全使用

使用 ${} 时,必须在 Java 层做白名单校验

// 白名单校验
private static final Set<String> ALLOWED_COLUMNS = Set.of("id", "name", "age", "create_time");

public List<User> findUsers(String column, String order) {
    if (!ALLOWED_COLUMNS.contains(column)) {
        throw new IllegalArgumentException("非法排序列: " + column);
    }
    if (!"ASC".equalsIgnoreCase(order) && !"DESC".equalsIgnoreCase(order)) {
        throw new IllegalArgumentException("非法排序方向: " + order);
    }
    return userMapper.findUsers(column, order);
}
1
2
3
4
5
6
7
8
9
10
11
12

原则${} 的值必须来自程序可控的白名单,永远不要直接接收用户输入

# 4. IN 查询的 #{} 用法

<!-- foreach + `#{}`,安全处理 IN 列表 -->
<select id="findByIds" resultType="User">
    SELECT * FROM user WHERE id IN
    <foreach collection="ids" item="id" open="(" separator="," close=")">
        #{id}
    </foreach>
</select>
1
2
3
4
5
6
7

每个 IN 列表项都会生成一个 ? 占位符,通过 PreparedStatement 安全设值。

# 八、常见面试追问

# Q1:#{}${} 哪个先解析?

${} 先解析。MyBatis 的 SQL 解析分两个阶段:动态 SQL 阶段先处理 ${}(TextSqlNode),然后 SqlSourceBuilder 阶段再处理 #{}

# Q2:#{} 替换为 ? 后,参数是怎么设置到 PreparedStatement 的?

通过 DefaultParameterHandler.setParameters() 方法,遍历 ParameterMapping 列表,调用每个参数对应的 TypeHandler.setParameter(),最终调用 PreparedStatement.setXxx() 完成参数绑定。

# Q3:${} 拼接的值会被二次处理吗?

会。${} 替换发生在 #{} 替换之前,所以 ${} 替换后的内容如果包含 #{} 标记,还会被二次解析。这也是一种潜在的安全风险。

# Q4:为什么 PreparedStatement 的 ? 不能用在表名位置?

因为数据库在编译 SQL 时需要确定查询的表结构(列信息、索引等)来生成执行计划。? 是参数占位符,数据库无法对一个未知的表生成执行计划。表名属于 SQL 的结构部分(标识符),不是数据部分(值)。

# Q5:MyBatis 默认用 #{} 还是 ${}

MyBatis 不强制默认,但在 XML 映射中,只要能用 #{} 的地方都应该用 #{},只有在表名、列名等标识符位置才需要用 ${}

# 九、总结

记住三个核心要点:

1. `#{}` 是预编译占位符 → 替换为 ? → PreparedStatement.setXxx() → 安全
   `${}` 是字符串拼接 → 直接替换为值的字符串 → 无参数化 → 有注入风险

2. 预编译防注入的原理 = 语法解析与参数填充分离
   SQL 模板先编译确定语法结构,参数值只作为数据,无法改变语法

3. `${}` 只在标识符位置(表名、列名、ORDER BY)使用
   使用时必须做白名单校验,永远不直接接收用户输入
1
2
3
4
5
6
7
8
9
10

面试回答模板

#{} 是预编译占位符,MyBatis 在解析时会将其替换为 ?,通过 PreparedStatement 的 setXxx() 方法设置参数值。由于 SQL 模板在编译阶段就已经确定了语法结构,参数值只作为数据处理,无法改变 SQL 的语义,因此能有效防止 SQL 注入。

${} 是字符串拼接,MyBatis 在解析时会直接将值替换为字符串拼接到 SQL 中,不经过参数化处理,因此存在 SQL 注入风险。

在使用上,条件值应该使用 #{};只有在表名、列名、ORDER BY 等标识符位置,因为 PreparedStatement 的 ? 不能用于标识符,才需要使用 ${},但必须做白名单校验。